// MTUI "server" - this just acts as the backend for mtui, controlling the
// player, queue, etc. It's entirely independent from tui-lib/UI.

'use strict'

import {readFile, writeFile} from 'node:fs/promises'
import EventEmitter from 'node:events'
import os from 'node:os'

import shortid from 'shortid'

import {getDownloaderFor} from './downloaders.js'
import {getMetadataReaderFor} from './metadata-readers.js'
import {getPlayer, GhostPlayer} from './players.js'
import RecordStore from './record-store.js'

import {
  getTimeStringsFromSec,
  shuffleArray,
  throttlePromise,
} from './general-util.js'

import {
  isGroup,
  isTrack,
  flattenGrouplike,
  parentSymbol,
} from './playlist-utils.js'

async function download(item, record) {
  if (isGroup(item)) {
    // TODO: Download all children (recursively), show a confirmation prompt
    // if there are a lot of items (remember to flatten).
    return
  }

  // You can't download things that aren't tracks!
  if (!isTrack(item)) {
    return
  }

  // Don't start downloading an item if we're already downloading it!
  if (record.downloading) {
    return
  }

  const arg = item.downloaderArg
  record.downloading = true
  try {
    return await getDownloaderFor(arg)(arg)
  } finally {
    record.downloading = false
  }
}

class QueuePlayer extends EventEmitter {
  constructor({
    getPlayer,
    getRecordFor
  }) {
    super()

    this.id = shortid.generate()

    this.player = null
    this.playingTrack = null
    this.queueGrouplike = {name: 'Queue', isTheQueue: true, items: []}
    this.pauseNextTrack = false
    this.queueEndMode = 'end' // end, loop, shuffle
    this.playedTrackToEnd = false
    this.timeData = null
    this.time = null

    this.alwaysStartPaused = false
    this.waitWhenDonePlaying = false

    this.getPlayer = getPlayer
    this.getRecordFor = getRecordFor
  }

  async setup() {
    this.player = await this.getPlayer()

    if (!this.player) {
      return {
        error: "Sorry, it doesn't look like there's an audio player installed on your computer. Can you try installing MPV (https://mpv.io) or SoX?"
      }
    }

    this.player.on('printStatusLine', data => {
      if (this.playingTrack) {
        const oldTimeData = this.timeData
        this.timeData = data
        this.time = data.curSecTotal
        this.emit('received time data', data, oldTimeData)
      }
    })

    return true
  }

  queue(topItem, afterItem = null, {movePlayingTrack = true} = {}) {
    const { items } = this.queueGrouplike
    const newTrackIndex = items.length

    // The position which new tracks should be added at, if afterItem is
    // passed.
    const afterIndex = afterItem && items.indexOf(afterItem)

    // Keeps track of how many tracks have been added; this is used so that
    // a whole group can be queued in order after a given item.
    let grouplikeOffset = 0

    // Keeps track of how many tracks have been removed (times -1); this is
    // used so we queue tracks at the intended spot.
    let removeOffset = 0

    const recursivelyAddTracks = item => {
      // For groups, just queue all children.
      if (isGroup(item)) {
        for (const child of item.items) {
          recursivelyAddTracks(child)
        }

        return
      }

      // If the item isn't a track, it can't be queued.
      if (!isTrack(item)) {
        return
      }

      // You can't put the same track in the queue twice - we automatically
      // remove the old entry. (You can't for a variety of technical reasons,
      // but basically you either have the display all bork'd, or new tracks
      // can't be added to the queue in the right order (because Object.assign
      // is needed to fix the display, but then you end up with a new object
      // that doesn't work with indexOf).)
      if (items.includes(item)) {
        // HOWEVER, if the "moveCurrentTrack" option is false, and that item
        // is the one that's currently playing, we won't do anything with it
        // at all.
        if (!movePlayingTrack && item === this.playingTrack) {
          return
        }

        const removeIndex = items.indexOf(item)
        items.splice(removeIndex, 1)

        // If the item we removed was positioned before the insertion index,
        // we need to shift that index back one, so it's placed after the same
        // intended track.
        if (removeIndex <= afterIndex) {
          removeOffset--
        }
      }

      if (afterItem === 'FRONT') {
        items.unshift(item)
      } else if (afterItem) {
        items.splice(afterIndex + 1 + grouplikeOffset + removeOffset, 0, item)
      } else {
        items.push(item)
      }

      grouplikeOffset++
    }

    recursivelyAddTracks(topItem)
    this.emit('queue', topItem, afterItem, {movePlayingTrack})
    this.emitQueueUpdated()

    // This is the first new track, if a group was queued.
    const newTrack = items[newTrackIndex]

    return newTrack
  }

  distributeQueue(topItem, {how = 'evenly', rangeEnd = 'end-of-queue'} = {}) {
    let grouplike
    if (isTrack(topItem)) {
      grouplike = {items: [topItem]}
    } else {
      grouplike = topItem
    }

    const { items } = this.queueGrouplike
    const newTracks = flattenGrouplike(grouplike).items.filter(isTrack)

    // Expressly do an initial pass and unqueue the items we want to queue -
    // otherwise they would mess with the math we do afterwords.
    for (const item of newTracks) {
      if (items.includes(item)) {
        /*
        if (!movePlayingTrack && item === this.playingTrack) {
          // NB: if uncommenting this code, splice item from newTracks and do
          // continue instead of return!
          return
        }
        */
        items.splice(items.indexOf(item), 1)
      }
    }

    const distributeStart = items.indexOf(this.playingTrack) + 1

    let distributeEnd
    if (rangeEnd === 'end-of-queue') {
      distributeEnd = items.length
    } else if (typeof rangeEnd === 'number') {
      distributeEnd = Math.min(items.length, rangeEnd)
    } else {
      throw new Error('Invalid rangeEnd: ' + rangeEnd)
    }

    const distributeSize = distributeEnd - distributeStart

    if (how === 'evenly') {
      let offset = 0
      for (const item of newTracks) {
        const insertIndex = distributeStart + Math.floor(offset)
        items.splice(insertIndex, 0, item)
        offset++
        offset += distributeSize / newTracks.length
      }
    } else if (how === 'randomly') {
      const indexes = newTracks.map(() => Math.floor(Math.random() * distributeSize))
      indexes.sort()
      for (let i = 0; i < newTracks.length; i++) {
        const item = newTracks[i]
        const insertIndex = distributeStart + indexes[i] + i
        items.splice(insertIndex, 0, item)
      }
    }

    this.emit('distribute queue', topItem, {how, rangeEnd})
    this.emitQueueUpdated()
  }

  unqueue(topItem, focusItem = null) {
    // This function has support to unqueue groups - it removes all tracks in
    // the group recursively. (You can never unqueue a group itself from the
    // queue listing because groups can't be added directly to the queue.)

    const { items } = this.queueGrouplike

    const recursivelyUnqueueTracks = item => {
      // For groups, just unqueue all children. (Groups themselves can't be
      // added to the queue, so we don't need to worry about removing them.)
      if (isGroup(item)) {
        for (const child of item.items) {
          recursivelyUnqueueTracks(child)
        }

        return
      }

      // Don't unqueue the currently-playing track - this usually causes more
      // trouble than it's worth.
      if (item === this.playingTrack) {
        return
      }

      // If we're unqueueing the item which is currently focused by the cursor,
      // just move the cursor ahead.
      if (item === focusItem) {
        focusItem = items[items.indexOf(focusItem) + 1]
        // ...Unless that puts it at past the end of the list, in which case, move
        // it behind the item we're removing.
        if (!focusItem) {
          focusItem = items[items.length - 2]
        }
      }

      if (items.includes(item)) {
        items.splice(items.indexOf(item), 1)
      }
    }

    recursivelyUnqueueTracks(topItem)
    this.emit('unqueue', topItem)
    this.emitQueueUpdated()

    return focusItem
  }

  replaceAllItems(newItems) {
    this.queueGrouplike.items = newItems
    this.emitQueueUpdated()
  }

  clearQueuePast(track) {
    const { items } = this.queueGrouplike
    const index = items.indexOf(track) + 1

    if (index < 0) {
      return
    } else if (index < items.indexOf(this.playingTrack)) {
      items.splice(index, items.length - index, this.playingTrack)
    } else {
      items.splice(index)
    }

    this.emit('clear queue past', track)
    this.emitQueueUpdated()
  }

  clearQueueUpTo(track) {
    const { items } = this.queueGrouplike
    const endIndex = items.indexOf(track)
    const startIndex = (this.playingTrack ? items.indexOf(this.playingTrack) + 1 : 0)

    if (endIndex < 0) {
      return
    } else if (endIndex < startIndex) {
      return
    } else {
      items.splice(startIndex, endIndex - startIndex)
    }

    this.emit('clear queue up to', track)
    this.emitQueueUpdated()
  }

  playSooner(item) {
    this.distributeQueue(item, {
      how: 'randomly',
      rangeEnd: this.queueGrouplike.items.indexOf(item)
    })
  }

  playLater(item) {
    this.skipIfCurrent(item)
    this.distributeQueue(item, {
      how: 'randomly'
    })
  }

  skipIfCurrent(track) {
    if (track === this.playingTrack) {
      this.playNext(track)
    }
  }

  shuffleQueue(pastPlayingTrackOnly = true) {
    const queue = this.queueGrouplike
    const index = (pastPlayingTrackOnly
      ? queue.items.indexOf(this.playingTrack) + 1 // This is 0 if no track is playing
      : 0)
    const initialItems = queue.items.slice(0, index)
    const remainingItems = queue.items.slice(index)
    const newItems = initialItems.concat(shuffleArray(remainingItems))
    queue.items = newItems
    this.emit('shuffle queue')
    this.emitQueueUpdated()
  }

  clearQueue() {
    // Clear the queue so that there aren't any items left in it (except for
    // the track that's currently playing).
    this.queueGrouplike.items = this.queueGrouplike.items
      .filter(item => item === this.playingTrack)
    this.emit('clear queue')
    this.emitQueueUpdated()
  }

  emitQueueUpdated() {
    this.emit('queue updated')
  }

  async stopPlaying() {
    // We emit this so the active play() call doesn't immediately start a new
    // track. We aren't *actually* about to play a new track.
    this.emit('playing new track')
    await this.player.kill()
    this.clearPlayingTrack()
  }

  async play(item, startTime = 0, forceStartPaused = false) {
    if (this.player === null) {
      throw new Error('Attempted to play before a player was loaded')
    }

    // If it's a group, play the first track.
    if (isGroup(item)) {
      item = flattenGrouplike(item).items[0]
    }

    // If there is no item (e.g. an empty group), well.. don't do anything.
    if (!item) {
      return
    }

    // If it's not a track, you can't play it.
    if (!isTrack(item)) {
      return
    }

    let playingThisTrack = true
    this.emit('playing new track')
    this.once('playing new track', () => {
      playingThisTrack = false
    })

    if (this.player instanceof GhostPlayer) {
      await this.#ghostPlay(item, startTime)
    } else if (!item.downloaderArg) {
      // No downloader argument? That's no good - stop here.
      // TODO: An error icon on this item, or something???
    } else {
      // If, by the time the track is downloaded, we're playing something
      // different from when the download started, assume that we just want to
      // keep listening to whatever new thing we started.

      const oldTrack = this.playingTrack

      const downloadFile = await this.download(item)

      if (this.playingTrack !== oldTrack) {
        return
      }

      this.timeData = null
      this.time = null
      this.playingTrack = item
      this.emit('playing details', this.playingTrack, oldTrack, startTime)
      this.emit('playing', this.playingTrack)

      await this.player.kill()
      if (this.alwaysStartPaused || forceStartPaused) {
        this.player.setPause(true)
      } else if (this.playedTrackToEnd) {
        this.player.setPause(this.pauseNextTrack)
        this.pauseNextTrack = false
        this.playedTrackToEnd = false
      } else {
        this.player.setPause(false)
      }
      await this.player.playFile(downloadFile, startTime)
    }

    // playingThisTrack now means whether the track played through to the end
    // (true), or was stopped by a different track being started (false).

    if (playingThisTrack) {
      this.playedTrackToEnd = true
      this.emit('done playing', this.playingTrack)
      if (!this.waitWhenDonePlaying) {
        this.playNext(item)
      }
    }
  }

  async #ghostPlay(item, startTime) {
    // If we're playing off a GhostPlayer, strip down the whole process.
    // Downloading is totally unnecessary, for example.

    this.timeData = null
    this.time = null
    this.playingTrack = item
    this.emit('playing', this.playingTrack)
    await this.player.playFile('-', startTime)
  }

  playNext(track, automaticallyQueueNextTrack = false) {
    if (!track) return false

    // Auto-queue is nice but it should only happen when the queue hasn't been
    // explicitly set to loop.
    automaticallyQueueNextTrack = (
      automaticallyQueueNextTrack &&
      this.queueEndMode === 'end')

    const queue = this.queueGrouplike
    let queueIndex = queue.items.indexOf(track)
    if (queueIndex === -1) return false
    queueIndex++

    if (queueIndex >= queue.items.length) {
      if (automaticallyQueueNextTrack) {
        const parent = track[parentSymbol]
        if (!parent) return false
        let index = parent.items.indexOf(track)
        let nextItem
        do {
          nextItem = parent.items[++index]
        } while (nextItem && !(isTrack(nextItem) || isGroup(nextItem)))
        if (!nextItem) return false
        this.queue(nextItem)
        queueIndex = queue.items.length - 1
      } else {
        return this.playNextAtQueueEnd()
      }
    }

    this.play(queue.items[queueIndex])
    return true
  }

  playPrevious(track, automaticallyQueuePreviousTrack = false) {
    if (!track) return false

    const queue = this.queueGrouplike
    let queueIndex = queue.items.indexOf(track)
    if (queueIndex === -1) return false
    queueIndex--

    if (queueIndex < 0) {
      if (automaticallyQueuePreviousTrack) {
        const parent = track[parentSymbol]
        if (!parent) return false
        let index = parent.items.indexOf(track)
        let previousItem
        do {
          previousItem = parent.items[--index]
        } while (previousItem && !(isTrack(previousItem) || isGroup(previousItem)))
        if (!previousItem) return false
        this.queue(previousItem, 'FRONT')
        queueIndex = 0
      } else {
        return false
      }
    }

    this.play(queue.items[queueIndex])
    return true
  }

  playFirst() {
    const queue = this.queueGrouplike
    if (queue.items.length) {
      this.play(queue.items[0])
      return true
    }
    return false
  }

  playNextAtQueueEnd() {
    switch (this.queueEndMode) {
      case 'loop':
        this.playFirst()
        return true
      case 'shuffle':
        this.shuffleQueue(false)
        this.playFirst()
        return true
      case 'end':
      default:
        this.clearPlayingTrack()
        return false
    }
  }

  async playOrSeek(item, time) {
    if (!isTrack(item)) {
      // This only makes sense to call with individual tracks!
      return
    }

    if (item === this.playingTrack) {
      this.seekTo(time)
    } else {
      // Queue the track, but only if it's not already in the queue, so that we
      // respect an existing queue order.
      const queue = this.queueGrouplike
      const queueIndex = queue.items.indexOf(item)
      if (queueIndex === -1) {
        this.queue(item, this.playingTrack)
      }

      this.play(item, time)
    }
  }

  clearPlayingTrack() {
    if (this.playingTrack !== null) {
      const oldTrack = this.playingTrack
      this.playingTrack = null
      this.timeData = null
      this.time = null
      this.emit('playing details', null, oldTrack, 0)
      this.emit('playing', null)
    }
  }

  async download(item) {
    return download(item, this.getRecordFor(item))
  }

  seekAhead(seconds) {
    this.time += seconds
    this.player.seekAhead(seconds)
    this.emit('seek ahead', +seconds)
  }

  seekBack(seconds) {
    if (this.time < seconds) {
      this.time = 0
    } else {
      this.time -= seconds
    }
    this.player.seekBack(seconds)
    this.emit('seek back', +seconds)
  }

  seekTo(timeInSecs) {
    this.time = timeInSecs
    this.player.seekTo(timeInSecs)
    this.emit('seek to', +timeInSecs)
  }

  seekTo(seconds) {
    this.player.seekTo(seconds)
  }

  seekToStart() {
    this.player.seekToStart()
  }

  togglePause() {
    this.player.togglePause()
    this.emit('toggle pause')
  }

  setPause(value) {
    this.player.setPause(value)
    this.emit('set pause', !!value)
  }

  toggleLoop() {
    this.player.toggleLoop()
    this.emit('toggle loop')
  }

  setLoop(value) {
    this.player.setLoop(value)
    this.emit('set loop', !!value)
  }

  volumeUp(amount = 10) {
    this.player.volUp(amount)
    this.emit('volume up', +amount)
  }

  volumeDown(amount = 10) {
    this.player.volDown(amount)
    this.emit('volume down', +amount)
  }

  setVolume(value) {
    this.player.setVolume(value)
    this.emit('set volume', +value)
  }

  setVolumeMultiplier(value) {
    this.player.setVolumeMultiplier(value)
  }

  fadeIn() {
    return this.player.fadeIn()
  }

  setPauseNextTrack(value) {
    this.pauseNextTrack = !!value
    this.emit('set pause next track', !!value)
  }

  setLoopQueueAtEnd(value) {
    this.loopQueueAtEnd = !!value
    this.emit('set loop queue at end', !!value)
  }

  setDuration(duration) {
    if (this.player.setDuration) {
      setTimeout(() => this.player.setDuration(duration))
    }
  }

  get remainingTracks() {
    const index = this.queueGrouplike.items.indexOf(this.playingTrack)
    const length = this.queueGrouplike.items.length
    if (index === -1) {
      return length
    } else {
      return length - index - 1
    }
  }

  get playSymbol() {
    if (this.player && this.playingTrack) {
      if (this.player.isPaused) {
        return '⏸'
      } else {
        return '▶'
      }
    } else {
      return '.'
    }
  }
}

export default class Backend extends EventEmitter {
  constructor({
    playerName = null,
    playerOptions = []
  } = {}) {
    super()

    this.playerName = playerName
    this.playerOptions = playerOptions

    if (playerOptions.length && !playerName) {
      throw new Error(`Must specify playerName to specify playerOptions`);
    }

    this.queuePlayers = []
    this.alwaysStartPaused = false
    this.waitWhenDonePlaying = false

    this.hasAnnouncedJoin = false
    this.sharedSourcesMap = Object.create(null)
    this.sharedSourcesGrouplike = {
      name: 'Shared Sources',
      isPartySources: true,
      items: []
    }

    this.recordStore = new RecordStore()
    this.throttleMetadata = throttlePromise(10)
    this.metadataDictionary = {}

    this.rootDirectory = os.homedir() + '/.mtui'
    this.metadataPath = this.rootDirectory + '/track-metadata.json'
  }

  async setup() {
    const error = await this.addQueuePlayer()
    if (error.error) {
      return error
    }

    await this.loadMetadata()

    return true
  }

  async addQueuePlayer() {
    const queuePlayer = new QueuePlayer({
      getPlayer: () => getPlayer(this.playerName, this.playerOptions),
      getRecordFor: item => this.getRecordFor(item)
    })

    const error = await queuePlayer.setup()
    if (error.error) {
      return error
    }

    queuePlayer.alwaysStartPaused = this.alwaysStartPaused
    queuePlayer.waitWhenDonePlaying = this.waitWhenDonePlaying

    this.queuePlayers.push(queuePlayer)
    this.emit('added queue player', queuePlayer)

    for (const event of [
      'clear queue',
      'clear queue past',
      'clear queue up to',
      'distribute queue',
      'done playing',
      'playing',
      'playing details',
      'queue',
      'queue updated',
      'received time data',
      'seek ahead',
      'seek back',
      'seek to',
      'set loop',
      'set loop queue at end',
      'set pause',
      'set pause next track',
      'set volume',
      'shuffle queue',
      'toggle loop',
      'toggle pause',
      'unqueue',
      'volume down',
      'volume up',
    ]) {
      queuePlayer.on(event, (...data) => {
        this.emit('QP: ' + event, queuePlayer, ...data)
      })
    }

    queuePlayer.on('playing', track => {
      if (track) {
        const metadata = this.getMetadataFor(track)
        queuePlayer.setDuration(metadata?.duration)
      }
    })

    return queuePlayer
  }

  removeQueuePlayer(queuePlayer) {
    if (this.queuePlayers.length > 1) {
      this.queuePlayers.splice(this.queuePlayers.indexOf(queuePlayer), 1)
      this.emit('removed queue player', queuePlayer)
    }
  }

  async readMetadata() {
    try {
      return JSON.parse(await readFile(this.metadataPath))
    } catch (error) {
      // Just stop. It's okay to fail to load metadata.
      return null
    }
  }

  async loadMetadata() {
    Object.assign(this.metadataDictionary, await this.readMetadata())
  }

  async saveMetadata() {
    const newData = Object.assign({}, await this.readMetadata(), this.metadataDictionary)
    await writeFile(this.metadataPath, JSON.stringify(newData))
  }

  getMetadataFor(item) {
    const key = this.metadataDictionary[item.downloaderArg]
    return this.metadataDictionary[key] || null
  }

  async processMetadata(item, reprocess = false, top = true) {
    let counter = 0

    if (isGroup(item)) {
      const results = await Promise.all(item.items.map(x => this.processMetadata(x, reprocess, false)))
      counter += results.reduce((acc, n) => acc + n, 0)
    } else if (isTrack(item)) process: {
      if (!reprocess && this.getMetadataFor(item)) {
        break process
      }

      await this.throttleMetadata(async () => {
        const filePath = await this.download(item)
        const metadataReader = getMetadataReaderFor(filePath)
        const data = await metadataReader(filePath)

        this.metadataDictionary[item.downloaderArg] = filePath
        this.metadataDictionary[filePath] = data
      })

      this.emit('processMetadata progress', this.throttleMetadata.queue.length)

      counter++
    }

    if (top) {
      await this.saveMetadata()
    }

    return counter
  }

  getRecordFor(item) {
    return this.recordStore.getRecord(item)
  }

  getDuration(item) {
    let noticedMissingMetadata = false

    const durationFn = (acc, track) => {
      const metadata = this.getMetadataFor(track)
      if (!metadata) noticedMissingMetadata = true
      return acc + (metadata && metadata.duration) || 0
    }

    let items
    if (isGroup(item)) {
      items = flattenGrouplike(item).items
    } else {
      items = [item]
    }

    const tracks = items.filter(isTrack)

    const seconds = tracks.reduce(durationFn, 0)

    let { duration: string } = getTimeStringsFromSec(0, seconds)
    const approxSymbol = noticedMissingMetadata ? '+' : ''
    string += approxSymbol

    return {seconds, string, noticedMissingMetadata, approxSymbol}
  }

  setAlwaysStartPaused(value) {
    this.alwaysStartPaused = !!value
    for (const queuePlayer of this.queuePlayers) {
      queuePlayer.alwaysStartPaused = !!value
    }
  }

  setWaitWhenDonePlaying(value) {
    this.waitWhenDonePlaying = !!value
    for (const queuePlayer of this.queuePlayers) {
      queuePlayer.waitWhenDonePlaying = !!value
    }
  }

  async stopPlayingAll() {
    for (const queuePlayer of this.queuePlayers) {
      await queuePlayer.stopPlaying()
    }
  }

  async download(item) {
    return download(item, this.getRecordFor(item))
  }

  showLogMessage(messageInfo) {
    this.emit('log message', messageInfo)
  }

  setPartyNickname(nickname) {
    this.emit('set party nickname', nickname)
  }

  announceJoinParty() {
    this.emit('announce join party')
  }

  setHasAnnouncedJoin(hasAnnouncedJoin) {
    this.hasAnnouncedJoin = hasAnnouncedJoin
  }

  loadSharedSources(socketId, sharedSources) {
    if (socketId in this.sharedSourcesMap) {
      return
    }

    this.sharedSourcesMap[socketId] = sharedSources

    sharedSources[parentSymbol] = this.sharedSourcesGrouplike
    this.sharedSourcesGrouplike.items.push(sharedSources)

    this.emit('got shared sources', socketId, sharedSources)
  }

  sharedSourcesUpdated(socketId, sharedSources) {
    this.emit('shared sources updated', socketId, sharedSources)
  }

  shareWithParty(item) {
    this.emit('share with party', item)
  }
}
